Skip to content

feat(reservations): persist CRUD via raft WAL and enforce scheduling parity#385

Open
sgopinath1 wants to merge 5 commits into
ROCm:mainfrom
sgopinath1:job_res
Open

feat(reservations): persist CRUD via raft WAL and enforce scheduling parity#385
sgopinath1 wants to merge 5 commits into
ROCm:mainfrom
sgopinath1:job_res

Conversation

@sgopinath1

Copy link
Copy Markdown
Collaborator

Summary

Adds durable node reservations with scheduling enforcement, and preemption interaction.

  • Durability — Reservation create/update/delete go through WalOperation (Raft log replay), not snapshot-only state.
  • FlagsMAINT, IGNORE_JOBS, NO_HOLD_JOBS, and OVERLAP with validation (unknown flags rejected at API boundary).
  • Scheduling — Prospective backfill blocking for reserved nodes, reservation-targeting jobs prioritized in pending_jobs(), future-start window fit via now.max(res.start_time), and lifecycle hooks (purge_expired, end-time cancel with resv_overrun_minutes grace, hold pending jobs on delete/purge unless NO_HOLD_JOBS).
  • Preemption — Honors partition PreemptMode; skips jobs in active reservations unless the preemptor has a higher priority_tier; only considers running jobs on nodes the pending job could use.
  • Display / APIscontrol/sinfo/smd reservation state; PendingReason::ReservedMaintenance / ReservationDeleted; proto JobInfo.reservation, reservation_maint, ReservationInfo.state; squeue %v column.

Design notes

  • Spur always runs Raft; reservations now follow the same propose → apply path as jobs and nodes.
  • hold_jobs_for_deleted_reservation sets PendingReason::ReservationDeleted after hold_job (not WAL-persisted; same pattern as other hold reasons).
  • IGNORE_JOBS bypasses running-job overlap checks at create time; OVERLAP / MAINT allow storage overlap between reservations.

Test plan

  • cargo fmt --check
  • cargo clippy --workspace --exclude spur-ffi --all-targets --locked (RUSTFLAGS=-D warnings)
  • cargo test --locked (unit tests across spur-core, spur-sched, spurctld, spur-cli, spurdbd)
  • Unit: overlap validation, priority ordering, resv_overrun grace, preemption immunity, node-overlap gating, purge_expired hold behavior, WAL apply for reservation CRUD
  • E2E: tests/native_host/e2e/test_reservations.py on bare-metal lab (create/list/delete, unauthorized block, authorized schedule, busy-node rejection)

Copilot AI review requested due to automatic review settings July 5, 2026 07:29
@codecov-commenter

codecov-commenter commented Jul 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.80974% with 235 lines in your changes missing coverage. Please review.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #385      +/-   ##
==========================================
+ Coverage   68.22%   68.67%   +0.44%     
==========================================
  Files         134      134              
  Lines       36087    37173    +1086     
==========================================
+ Hits        24619    25525     +906     
- Misses      11468    11648     +180     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds durable, Raft-persisted node reservations to Spur and enforces reservation semantics across scheduling, backfill, display surfaces, and preemption behavior.

Changes:

  • Persist reservation CRUD via Raft WAL operations and apply-path replay.
  • Enforce reservation eligibility in scheduling/backfill (including future overlap fencing) and introduce reservation-driven pending reasons / formatting.
  • Integrate reservations into UX/API surfaces (proto fields, CLI display/format codes) and refine preemption logic around active reservations.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/native_host/e2e/test_reservations.py Adds native-host E2E coverage for reservation create/list/delete and scheduling enforcement.
proto/slurm.proto Extends public proto with reservation fields (job reservation, node maint flag, reservation flags/state).
crates/spurdbd/src/server.rs Populates new JobInfo.reservation field for accounting responses.
crates/spurctld/src/server.rs Validates/accepts reservation flags at API boundary; lists reservation flags/state; annotates nodes with reservation info.
crates/spurctld/src/scheduler_loop.rs Runs reservation lifecycle hooks each scheduler tick; expands preemption logic to account for reservations and partition policy.
crates/spurctld/src/cluster.rs Moves reservation CRUD onto propose→apply Raft path; adds validation, purge/expiry handling, and reservation-driven pending ordering/reasons.
crates/spur-tests/src/t55_format.rs Adds formatting parity mappings for new pending reasons.
crates/spur-tests/src/t50_core.rs Updates core tests for new reservation flags field.
crates/spur-tests/src/t07_sched.rs Updates scheduler tests for new reservation flags field.
crates/spur-sched/src/backfill.rs Adds prospective reservation overlap checks and reservation window fit constraints to backfill scheduling.
crates/spur-core/src/wal.rs Introduces WAL variants for reservation create/update/delete plus serialization round-trip tests.
crates/spur-core/src/reservation.rs Adds reservation flags, overlap helpers, node-list normalization, and prospective overlap logic.
crates/spur-core/src/job.rs Introduces new PendingReason values for reservation maintenance/deletion.
crates/spur-core/src/config.rs Adds resv_overrun_minutes scheduler config for reservation end grace period.
crates/spur-cli/src/squeue.rs Adds %v job format spec for reservation name display.
crates/spur-cli/src/smd.rs Displays maint vs resv for nodes with active maintenance reservations.
crates/spur-cli/src/sinfo.rs Displays maint vs resv for idle nodes with active maintenance reservations.
crates/spur-cli/src/scontrol.rs Adds --flags to reservation creation and shows reservation/node reservation-related fields.
crates/spur-cli/src/format_engine.rs Adds header label for %v reservation column.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/spur-core/src/reservation.rs Outdated
Comment thread crates/spur-core/src/reservation.rs Outdated
Comment thread crates/spurctld/src/server.rs
Comment thread crates/spurctld/src/server.rs
Comment thread crates/spurctld/src/server.rs
Comment thread tests/native_host/e2e/test_reservations.py Outdated
@sgopinath1 sgopinath1 changed the title feat(reservations): persist CRUD via raft WAL and enforce scheduling parity feat(reservations): persist CRUD via raft WAL and enforce scheduling parity Jul 5, 2026

@yansun1996 yansun1996 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please see the inline comments below.

if reservations.iter().any(|r| r.name == res.name) {
/// Create a new reservation (validated, persisted via Raft).
pub fn create_reservation(&self, mut res: Reservation) -> anyhow::Result<()> {
if self.reservations.read().iter().any(|r| r.name == res.name) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: TOCTOU race on reservation name uniqueness. This check reads self.reservations before propose(); two concurrent create_reservation calls for the same name can both pass it before either commits. apply_operation's ReservationCreate arm never re-checks for an existing name and unconditionally pushes, so duplicates can land. Worse, ReservationDelete's apply arm uses retain(|r| r.name != *name), which would then remove both duplicates on a single delete. The uniqueness invariant needs to be enforced inside apply_operation — the single serialized point all replicas agree on — not just at propose time. Once fixed, a regression test spawning two same-name creates and asserting only one survives would pin down the invariant.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. apply_operation now dedupes ReservationCreate at the serialized apply point; ClientResponse.reservation_created reports whether it applied, and the RPC returns AlreadyExists otherwise. Added concurrent_reservation_create_keeps_single_entry test.

Comment thread crates/spurctld/src/cluster.rs Outdated
name: name.to_string(),
})?;

if !res.flags.no_hold_jobs {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: no_hold_jobs doesn't actually let jobs be rescheduled. Skipping hold_jobs_for_deleted_reservation here (and in purge_expired_reservations) never clears job.spec.reservation — no code path does. reservation_block() unconditionally returns Some(PendingReason::Reservation) when the named reservation no longer exists, and pending_jobs() filters those out entirely. So jobs from a no_hold_jobs-deleted reservation are still permanently excluded from scheduling — functionally identical to being held, just without the Held label. A minimal regression test (extend purge_expired_holds_pending_reservation_jobs below at line ~5363): create a reservation with no_hold_jobs, submit a job against it, delete/expire it, assert the job is still eligible via pending_jobs().

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Delete/purge now clears job.spec.reservation via detach_jobs_from_deleted_reservation_jobs in the WAL apply path, so reservation_block() no longer filters those jobs. Added no_hold_jobs_reservation_delete_does_not_permanently_block_job and purge variant tests.

}
}

fn reservation_fence_reason(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: this fences on any node being reservation-blocked, even if 99 other free nodes would satisfy the job, because it continues before the more accurate Resources/NodeDown/Priority classification runs. It's diagnostics-only (actual placement uses prospective_overlap independently in spur-sched), but in any cluster with an active/upcoming reservation covering even one node, unrelated pending jobs can be mislabeled ReqNodeNotAvail/ReservedMaintenance in squeue/scontrol when the real reason is unrelated.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Fence reasons are only set when all candidate nodes are reservation-blocked (job_candidate_node_names). Diagnostics-only path; scheduling still uses prospective_overlap independently.

Comment thread crates/spurctld/src/cluster.rs Outdated
let Some(job) = jobs.get(&job_id) else {
return Ok(());
};
if job.requeue_count >= MAX_REQUEUE {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (pre-existing gap, not introduced by this PR): once requeue_count >= MAX_REQUEUE, the job returns Ok(()) and stays in JobState::Preempted forever — neither is_terminal() nor is_active() — so it never completes, gets cancelled, or gets picked up by any further scheduler hook. Consider transitioning to Cancelled with a distinguishing exit reason once retries are exhausted.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Exhausted requeues now transition to held Pending with JobHoldMaxRequeue (configurable via max_batch_requeue in spur.conf) instead of staying in Preempted forever. Admin can release with scontrol release.

Comment thread crates/spurctld/src/server.rs Outdated
}

#[allow(clippy::result_large_err)]
fn reservation_rpc_status(err: impl std::fmt::Display) -> Status {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: classifying gRPC status by substring-matching anyhow::Error::to_string() is fragile — any future rewording of an error message (including ones from hostlist::expand()/normalize_node_list(), which this function doesn't control) silently changes the client-facing status code. Consider a typed error enum or downcast_ref at the ClusterManager boundary instead.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Introduced typed ReservationError; reservation_rpc_status maps variants to gRPC codes directly — no to_string() substring matching.

Comment thread crates/spurctld/src/server.rs Outdated
}
}

#[allow(clippy::result_large_err)]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: reservation_rpc_status returns a bare Status, not a Result<_, E>, so #[allow(clippy::result_large_err)] can never fire here — looks like a copy-paste leftover, safe to remove.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — removed the unused allow.


/// Returns true when `[start, start+duration)` intersects a reservation on `node`
/// and the job is not authorized for that reservation.
fn start_overlaps_reservation(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: this duplicates prospective_overlap in spur-core/src/reservation.rs — both implement "does [start, start+duration) intersect a reservation window this job isn't authorized for", just parameterized differently (now vs. an arbitrary future start). Consider generalizing prospective_overlap to take an explicit at: DateTime<Utc> and having this call it, so future changes to the authorization rule can't drift between the two call sites.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added prospective_overlap_at(job, res, node, at, duration) in spur-core; backfill calls it so the authorization rule lives in one place.

Comment thread proto/slurm.proto
// Array jobs
uint32 array_job_id = 50;
uint32 array_task_id = 51;
string reservation = 52;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: reservation = 52 is grouped under the // Array jobs comment above array_job_id/array_task_id, but it isn't array-related. Consider splitting into its own // Reservation comment line for readability — the field number itself (52) is fine as-is.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — moved under its own // Reservation comment, field number unchanged.

}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_reservation_create_update_delete() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage: nearby tests (apply_reservation_create_update_delete, snapshot_and_restore) prove CRUD round-trips and snapshot/restore work, but none proves durability across an actual restart via WAL replay, which is this PR's headline claim. No reservation test drops a ClusterManager and starts a new one against the same on-disk raft log directory to assert the reservation reappears from replay (as opposed to a hand-invoked snapshot restore into a fresh dir2). Worth adding a reservation_survives_wal_replay test reusing this same TempDir/test_cluster scaffolding but restarting against dir.path().

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added reservation_survives_wal_replay: drops ClusterManager, restarts against the same Raft dir, asserts reservation survives replay.

}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn purge_expired_holds_pending_reservation_jobs() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage: only the default (holding) path is tested here. A minimal no_hold_jobs_reservation_delete_does_not_permanently_block_job test — create a reservation with no_hold_jobs set, submit a job against it, delete/expire the reservation, assert the job is still eligible via pending_jobs() — would catch the gap flagged above on delete_reservation/purge_expired_reservations.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added no_hold_jobs_reservation_delete_does_not_permanently_block_job and purge variant — create with no_hold_jobs, submit job, delete/expire, assert job is still in pending_jobs().

…parity

Route reservation create/update/delete through WalOperation so changes
survive log replay, not just periodic snapshots. Add MAINT, IGNORE_JOBS,
and NO_HOLD_JOBS flags with overlap validation, prospective backfill
blocking, lifecycle enforcement (purge, end-time cancel, hold on delete),
and scontrol/sinfo display updates including maint-fenced pending reasons.
Add OVERLAP flag with reservation overlap validation, resv_overrun_minutes
grace for end-of-window job cancel, reservation-first pending job ordering,
PreemptMode-aware preemption with reservation immunity unless priority_tier
is higher, JobInfo.reservation proto field, and squeue %v column.
Reject unknown reservation flags, fix backfill window for future starts,
gate preemption on node overlap, hold pending jobs on purge_expired,
add WAL apply and e2e coverage, and fix spurdbd JobInfo.reservation.
Block unauthorized jobs on active reservations in prospective_overlap,
treat unknown running-job end times as occupied, split reservation RPC
errors (internal vs not_found vs invalid_argument), OR maint flags across
overlapping reservations, and wait on specific job states in e2e tests.
Make max-requeue holds and reservation delete/purge job updates
WAL-atomic via extended JobStateChange and JobPriorityChange.
Add max_batch_requeue config, JobHoldMaxRequeue hold reason, overlap
and no_hold_jobs flags, accounting reservation tracking, and e2e coverage.
@sgopinath1

Copy link
Copy Markdown
Collaborator Author

Please see the inline comments below.

Thanks for reviewing @yansun1996. Please check the fixes and responses.

@yansun1996 yansun1996 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All 10 of my original findings are genuinely fixed in the latest push (d3cd08c) — verified by reading the actual code, not just the reply text:

  1. TOCTOU race on reservation names — fixed via a proper re-check in apply_operation's serialized ReservationCreate arm (reservation_created flag)
  2. no_hold_jobs permanent block — fixed via detach_jobs_from_deleted_reservation_jobs clearing job.spec.reservation
  3. reservation_fence_reason over-fencing — fixed, now requires all candidate nodes blocked
  4. Preempted jobs stuck past max requeue — fixed via hold_job_at_max_requeue
  5. Brittle string-matched RPC status — fixed with a typed ReservationError enum
  6. Dead clippy attribute — removed
  7. Duplicated overlap logic — unified via prospective_overlap_at
  8. Proto field grouping — reservation = 52 now has its own comment header
  9. WAL-replay durability — new test properly restarts against the same on-disk dir
  10. no_hold_jobs regression coverage — dedicated tests added

Also confirmed Copilot's earlier 5 findings were genuinely addressed in the prior commit.

CI hasn't finished running against this commit yet — merge once it's green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants